docs-kit 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +81 -0
- data/app/components/docs_ui/landing.rb +2 -2
- data/app/components/docs_ui/markdown.rb +8 -4
- data/app/components/docs_ui/markdown_images.rb +73 -0
- data/app/components/docs_ui/meta_tags.rb +2 -2
- data/app/components/docs_ui/page.rb +38 -11
- data/app/components/docs_ui/shell.rb +18 -4
- data/app/components/docs_ui/sidebar.rb +2 -2
- data/app/controllers/docs_kit/llms_controller.rb +5 -2
- data/lib/docs_kit/configuration.rb +67 -1
- data/lib/docs_kit/engine.rb +7 -0
- data/lib/docs_kit/llms_text.rb +1 -1
- data/lib/docs_kit/mcp_server.rb +2 -2
- data/lib/docs_kit/version.rb +1 -1
- data/lib/docs_kit.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: feed239eb09deeafd1a9dc6e92e91d192c14f927fa562105139f4b16473d1a60
|
|
4
|
+
data.tar.gz: 6149bf7b9509fb611ce86e9c183a38c027ce77addee69669be41a0ce85fb292d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5af123f6815e81ccad67dcb7737836c0a2727fb60e963bfc16d6ff0a6540325ca1a67fa2aaaea2ff824ba3b36f2210d8fb4aaab43f097195ec7f020cbfc8c54e
|
|
7
|
+
data.tar.gz: 32e24110ca11cfba1c795d60ea93d6ecb2f1e42af912274ebc816718cd235d93e217bb1ac2a6082b08039eaefe162c6b8287f4ed328c685ab3c669c840e2e359
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.3.0] — 2026-10-13
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Markdown links drop a script-bearing href.** `[text](javascript:…)`,
|
|
10
|
+
`data:`, `vbscript:` and any other scheme outside http(s), `mailto:` and
|
|
11
|
+
`tel:` now render the link text with no `<a>` (Phlex already refused
|
|
12
|
+
`javascript:`; `data:` and the rest slipped through). Scheme-less hrefs are
|
|
13
|
+
unchanged. The same guard `DocsUI::Markdown` images have.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **A per-request `brand` and `title_suffix`.** Both accept a zero-arity
|
|
18
|
+
callable (`c.brand = -> { Current.tenant_name }`), evaluated on every render:
|
|
19
|
+
the topbar, sidebar header, `<title>`, `og:site_name`, llms.txt and the MCP
|
|
20
|
+
server name show that request's value. A callable brand needs an explicit
|
|
21
|
+
`c.nav_storage_key`; `DocsKit.configure` raises without one. New readers
|
|
22
|
+
`Configuration#brand_text` / `#title_suffix_text`; a String brand renders
|
|
23
|
+
exactly as before. (#92)
|
|
24
|
+
- **Markdown images render.** `` in an `md` island now
|
|
25
|
+
renders a lazy-loaded `<img>` (it rendered nothing before). In a Rails view
|
|
26
|
+
a relative path resolves through the site's asset pipeline (`image_path`;
|
|
27
|
+
off a request it is kept as written); an absolute,
|
|
28
|
+
protocol-relative or root-relative URL is kept as written; the alt is the
|
|
29
|
+
description's plain text. A `javascript:`, `data:` or other non-http(s)
|
|
30
|
+
scheme renders no image. The page's `.md` twin exports it back to
|
|
31
|
+
``. (#94)
|
|
32
|
+
- **A dedicated docs import map.** `c.importmap_entry_point` (default
|
|
33
|
+
`"application"`) and `c.importmap` (an `Importmap::Map` or a callable
|
|
34
|
+
returning one, resolved per render) choose what the Shell's
|
|
35
|
+
`javascript_importmap_tags` loads, and `config.docs_kit.auto_pin = false` (in
|
|
36
|
+
`config/application.rb`) keeps the gem's `docs-nav` pin out of the host's
|
|
37
|
+
shared map. Unset, the head is unchanged. (#93)
|
|
38
|
+
- **`DocsUI::Page` title, description and eyebrow per instance.** The page now
|
|
39
|
+
reads `#title`, `#description` and `#eyebrow`, which default to the class
|
|
40
|
+
macros, so one page class can render many records (`def title = @widget.name`)
|
|
41
|
+
without an anonymous subclass per render. The `eyebrow`, `description` and
|
|
42
|
+
`on_page` macros inherit from a parent page when unset. Pages using only the
|
|
43
|
+
class macros render byte-identically. (#95)
|
|
44
|
+
|
|
45
|
+
## [1.2.1] — 2026-09-30 (with 1.2.0, same day)
|
|
46
|
+
|
|
5
47
|
### Upgrading from 1.1
|
|
6
48
|
|
|
7
49
|
```bash
|
data/README.md
CHANGED
|
@@ -193,6 +193,20 @@ DocsKit.configure do |c|
|
|
|
193
193
|
end
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
+
**One app, several hosts?** `brand` and `title_suffix` also take a zero-arity
|
|
197
|
+
callable, evaluated on every render — the topbar, the sidebar header, `<title>`,
|
|
198
|
+
`og:site_name`, llms.txt and the MCP server name all show that request's value.
|
|
199
|
+
A callable brand can't name the localStorage namespace (it must stay stable per
|
|
200
|
+
browser), so set `nav_storage_key` too; `DocsKit.configure` raises without it.
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
DocsKit.configure do |c|
|
|
204
|
+
c.brand = -> { Current.tenant_name } # Current < ActiveSupport::CurrentAttributes
|
|
205
|
+
c.title_suffix = -> { "#{Current.tenant_name} docs" } # optional; unset follows the brand
|
|
206
|
+
c.nav_storage_key = "docs"
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
196
210
|
The nav is **derived from the registry**, so you never hand-write it. Each
|
|
197
211
|
registry maps a heading to its authored pages (`Doc.nav_items`); a page that
|
|
198
212
|
isn't written yet is skipped, so there are no dead links. Register a page with
|
|
@@ -431,6 +445,37 @@ end
|
|
|
431
445
|
`DocsUI::Page` includes the kit, so inside `#content` you call the components
|
|
432
446
|
directly — `DocsUI::Section(...)`, `DocsUI::Code(...)` — no `render … .new`.
|
|
433
447
|
|
|
448
|
+
### One page class, many records
|
|
449
|
+
|
|
450
|
+
`title`, `eyebrow` and `description` are class macros by default, but the page
|
|
451
|
+
reads them through **instance** methods — override those when one page class
|
|
452
|
+
renders many records (one reference page per item):
|
|
453
|
+
|
|
454
|
+
```ruby
|
|
455
|
+
class Views::Docs::Widget < DocsUI::Page
|
|
456
|
+
eyebrow "Widgets"
|
|
457
|
+
|
|
458
|
+
def initialize(widget:) = @widget = widget
|
|
459
|
+
def title = @widget.name # masthead <h1> and <title>
|
|
460
|
+
def description = @widget.summary # meta/OG description (default: class macro, then #lead)
|
|
461
|
+
def content = md(@widget.notes)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# controller
|
|
465
|
+
def show = render_page(Views::Docs::Widget.new(widget: Widget.find(params[:id])))
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`eyebrow`, `description` and `on_page` inherit: a subclass that leaves one unset
|
|
469
|
+
uses its parent page's. `title` does not — each page names itself.
|
|
470
|
+
|
|
471
|
+
Such a page usually lives on its own route, outside the registry. If that route
|
|
472
|
+
has no `(.:format)` segment, the masthead's **Markdown** action (which links
|
|
473
|
+
`request.path + ".md"`) would 404 — hide it for that page:
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
def markdown_action? = false
|
|
477
|
+
```
|
|
478
|
+
|
|
434
479
|
### The authoring convention
|
|
435
480
|
|
|
436
481
|
One rule covers the whole kit: **the primary argument is positional; modifiers
|
|
@@ -1051,6 +1096,42 @@ server round-trip:
|
|
|
1051
1096
|
of three placements, auto-hiding on short pages. No server-side knowledge of
|
|
1052
1097
|
the headings, no per-page wiring.
|
|
1053
1098
|
|
|
1099
|
+
### A dedicated docs import map
|
|
1100
|
+
|
|
1101
|
+
By default the Shell loads the host's `application` entry point from
|
|
1102
|
+
`Rails.application.importmap`, and the engine adds the `docs-nav` pin to that
|
|
1103
|
+
map. A host with several import maps can give its docs their own entry point
|
|
1104
|
+
and map, and keep the docs controller out of every other page's preloads:
|
|
1105
|
+
|
|
1106
|
+
```ruby
|
|
1107
|
+
# config/application.rb — skip the engine's auto-pin into the shared map
|
|
1108
|
+
config.docs_kit.auto_pin = false
|
|
1109
|
+
|
|
1110
|
+
# config/initializers/docs_kit.rb — build the second map (importmap-rails has no
|
|
1111
|
+
# registry for extra maps; the host owns it) and point the docs chrome at it
|
|
1112
|
+
DOCS_IMPORTMAP = Importmap::Map.new.draw(Rails.root.join("config/importmaps/docs.rb"))
|
|
1113
|
+
|
|
1114
|
+
DocsKit.configure do |c|
|
|
1115
|
+
c.importmap_entry_point = "docs" # default "application"
|
|
1116
|
+
c.importmap = -> { DOCS_IMPORTMAP } # default nil → Rails.application.importmap
|
|
1117
|
+
end
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
```ruby
|
|
1121
|
+
# config/importmaps/docs.rb — drawn into the docs Importmap::Map above
|
|
1122
|
+
pin "docs"
|
|
1123
|
+
pin "@hotwired/turbo-rails", to: "turbo.min.js"
|
|
1124
|
+
pin "@hotwired/stimulus", to: "stimulus.min.js"
|
|
1125
|
+
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
|
1126
|
+
pin_all_from DocsKit::Engine.root.join("app/javascript/docs_kit/controllers"),
|
|
1127
|
+
under: "docs_kit/controllers", to: "docs_kit/controllers"
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
`c.importmap` takes an `Importmap::Map` or a callable returning one; a callable
|
|
1131
|
+
is resolved on every render, so a map rebuilt on code reload is picked up. The
|
|
1132
|
+
`docs` entry point registers the controllers with
|
|
1133
|
+
`eagerLoadControllersFrom("docs_kit/controllers", application)`.
|
|
1134
|
+
|
|
1054
1135
|
### On this page (auto-TOC)
|
|
1055
1136
|
|
|
1056
1137
|
`DocsUI::Page` renders it automatically. The placement is a strategy — set the
|
|
@@ -33,7 +33,7 @@ module DocsUI
|
|
|
33
33
|
include Phlex::Rails::Helpers::Request
|
|
34
34
|
|
|
35
35
|
def view_template
|
|
36
|
-
render DocsUI::Shell.new(title: landing.eyebrow || config.
|
|
36
|
+
render DocsUI::Shell.new(title: landing.eyebrow || config.brand_text) do
|
|
37
37
|
div(class: "mx-auto max-w-5xl") do
|
|
38
38
|
hero
|
|
39
39
|
feature_grid
|
|
@@ -78,7 +78,7 @@ module DocsUI
|
|
|
78
78
|
# (the one bit of markdown we honor, so a site can accent a word without HTML).
|
|
79
79
|
def heading
|
|
80
80
|
h1(class: "text-4xl font-bold tracking-tight md:text-5xl") do
|
|
81
|
-
(landing.title || config.
|
|
81
|
+
(landing.title || config.brand_text).to_s.split(/\*\*(.+?)\*\*/).each_with_index do |part, index|
|
|
82
82
|
next if part.empty?
|
|
83
83
|
|
|
84
84
|
index.odd? ? span(class: "text-primary") { part } : plain(part)
|
|
@@ -34,6 +34,9 @@ module DocsUI
|
|
|
34
34
|
TABLE_WRAPPER = "not-prose my-4 overflow-x-auto rounded-box border border-base-300"
|
|
35
35
|
TABLE_CLASSES = "table table-sm table-zebra"
|
|
36
36
|
|
|
37
|
+
#  → <img>, asset-pipeline resolved (see MarkdownImages).
|
|
38
|
+
include MarkdownImages
|
|
39
|
+
|
|
37
40
|
# Render source as INLINE markdown: no Prose wrapper div, and a single
|
|
38
41
|
# top-level paragraph is unwrapped so its inline children (strong/em/code/
|
|
39
42
|
# link) sit directly in the surrounding element. Used for a [:md, "…"] table
|
|
@@ -88,9 +91,7 @@ module DocsUI
|
|
|
88
91
|
|
|
89
92
|
def node_document(node) = visit_children(node)
|
|
90
93
|
|
|
91
|
-
def node_paragraph(node)
|
|
92
|
-
p { visit_children(node) }
|
|
93
|
-
end
|
|
94
|
+
def node_paragraph(node) = p { visit_children(node) }
|
|
94
95
|
|
|
95
96
|
# Demote so Markdown headings never collide with the page masthead/section
|
|
96
97
|
# headings: h1→h3, h2→h4, anything deeper caps at h4.
|
|
@@ -127,8 +128,11 @@ module DocsUI
|
|
|
127
128
|
render DocsUI::Code.new(node.string_content, lexer:)
|
|
128
129
|
end
|
|
129
130
|
|
|
131
|
+
# A link keeps its href only for an allowed scheme (MarkdownImages::
|
|
132
|
+
# LINK_SCHEMES) or none at all; otherwise the text renders bare.
|
|
130
133
|
def node_link(node)
|
|
131
|
-
|
|
134
|
+
href = link_href(node.url)
|
|
135
|
+
href ? a(href:) { visit_children(node) } : visit_children(node)
|
|
132
136
|
end
|
|
133
137
|
|
|
134
138
|
def node_list(node)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DocsUI
|
|
4
|
+
# DocsUI::Markdown's image handler: `` → a lazy-loaded
|
|
5
|
+
# <img>. A relative src resolves through the site's asset pipeline
|
|
6
|
+
# (image_path → the digested /assets URL, Propshaft or Sprockets); an absolute,
|
|
7
|
+
# protocol-relative or root-relative URL is kept as written. Off a request (an
|
|
8
|
+
# isolated render) the src is used verbatim — the DocsUI::Logo posture.
|
|
9
|
+
module MarkdownImages
|
|
10
|
+
include Phlex::Rails::Helpers::ImagePath
|
|
11
|
+
|
|
12
|
+
# A Markdown image sits in the prose like the kit's other bordered boxes.
|
|
13
|
+
IMAGE_CLASSES = "my-4 rounded-box border border-base-300"
|
|
14
|
+
|
|
15
|
+
# An image src kept verbatim: absolute, protocol-relative or root-relative.
|
|
16
|
+
ABSOLUTE_SRC = %r{\A(?:https?:)?/}i
|
|
17
|
+
|
|
18
|
+
# Any URL scheme. An image src may only carry http(s); a link href one of
|
|
19
|
+
# LINK_SCHEMES. Scheme-less URLs (relative, root-relative, protocol-relative,
|
|
20
|
+
# #anchor) always pass; javascript:, data:, vbscript: and the rest never do.
|
|
21
|
+
URL_SCHEME = /\A[a-z][a-z0-9+.-]*:/i
|
|
22
|
+
LINK_SCHEMES = %w[http https mailto tel].freeze
|
|
23
|
+
|
|
24
|
+
# The descendants whose string_content makes up the alt text; a soft or hard
|
|
25
|
+
# line break between them becomes a space so words don't run together.
|
|
26
|
+
ALT_TEXT_NODES = %i[text code].freeze
|
|
27
|
+
ALT_BREAK_NODES = %i[softbreak linebreak].freeze
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# An unsafe scheme renders nothing — the alt text is dropped with it rather
|
|
32
|
+
# than left behind as stray prose.
|
|
33
|
+
def node_image(node)
|
|
34
|
+
src = image_src(node.url)
|
|
35
|
+
return unless src
|
|
36
|
+
|
|
37
|
+
title = node.title.to_s
|
|
38
|
+
img(src:, alt: alt_text(node), title: (title unless title.empty?),
|
|
39
|
+
loading: "lazy", class: IMAGE_CLASSES)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Browsers strip ASCII whitespace/control characters before reading a
|
|
43
|
+
# scheme ("java\tscript:"), so the check does too.
|
|
44
|
+
def image_src(url)
|
|
45
|
+
bare = bare_url(url)
|
|
46
|
+
return url if bare.match?(ABSOLUTE_SRC)
|
|
47
|
+
return if bare.match?(URL_SCHEME)
|
|
48
|
+
|
|
49
|
+
view_context ? image_path(url) : url
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A link's href as written, or nil when it carries a scheme outside
|
|
53
|
+
# LINK_SCHEMES (the same whitespace-stripped scheme read as image_src).
|
|
54
|
+
def link_href(url)
|
|
55
|
+
scheme = bare_url(url)[URL_SCHEME]
|
|
56
|
+
return url if scheme.nil? || LINK_SCHEMES.include?(scheme.chomp(":").downcase)
|
|
57
|
+
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def bare_url(url) = url.to_s.gsub(/[\x00-\x20]/, "")
|
|
62
|
+
|
|
63
|
+
# Commonmarker 2 has no plain-text export, so the alt is the image's text
|
|
64
|
+
# (and inline code) descendants joined — emphasis markup is dropped.
|
|
65
|
+
def alt_text(node)
|
|
66
|
+
node.walk.filter_map do |n|
|
|
67
|
+
if ALT_TEXT_NODES.include?(n.type) then n.string_content
|
|
68
|
+
elsif ALT_BREAK_NODES.include?(n.type) then " "
|
|
69
|
+
end
|
|
70
|
+
end.join
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -52,7 +52,7 @@ module DocsUI
|
|
|
52
52
|
# The full title used for og:title, identical to what Shell puts in <title>:
|
|
53
53
|
# "Page · Suffix", or just the suffix on a title-less page (the home page).
|
|
54
54
|
def full_title
|
|
55
|
-
[@title, config.
|
|
55
|
+
[@title, config.title_suffix_text].compact.join(" · ")
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def description_meta
|
|
@@ -67,7 +67,7 @@ module DocsUI
|
|
|
67
67
|
def open_graph
|
|
68
68
|
meta(property: "og:title", content: full_title)
|
|
69
69
|
meta(property: "og:type", content: seo.og_type)
|
|
70
|
-
meta(property: "og:site_name", content: config.
|
|
70
|
+
meta(property: "og:site_name", content: config.brand_text)
|
|
71
71
|
open_graph_optional
|
|
72
72
|
end
|
|
73
73
|
|
|
@@ -14,6 +14,16 @@ module DocsUI
|
|
|
14
14
|
# render DocsUI::Section.new("Add the gem") { … }
|
|
15
15
|
# end
|
|
16
16
|
# end
|
|
17
|
+
#
|
|
18
|
+
# A page class that renders many records overrides the INSTANCE readers
|
|
19
|
+
# (#title, #description, #eyebrow) instead — they default to the class macros:
|
|
20
|
+
#
|
|
21
|
+
# class Views::Docs::Widget < DocsUI::Page
|
|
22
|
+
# eyebrow "Widgets"
|
|
23
|
+
# def initialize(widget:) = @widget = widget
|
|
24
|
+
# def title = @widget.name
|
|
25
|
+
# def content = md(@widget.notes)
|
|
26
|
+
# end
|
|
17
27
|
class Page < Phlex::HTML
|
|
18
28
|
include Phlex::Rails::Helpers::Routes
|
|
19
29
|
# #request drives the "Markdown" masthead action's href (request.path + .md).
|
|
@@ -31,35 +41,42 @@ module DocsUI
|
|
|
31
41
|
@title
|
|
32
42
|
end
|
|
33
43
|
|
|
44
|
+
# Inherited: a subclass that sets no eyebrow shows its parent page's.
|
|
34
45
|
def eyebrow(value = nil)
|
|
35
46
|
@eyebrow = value if value
|
|
36
|
-
@eyebrow
|
|
47
|
+
@eyebrow || (superclass.eyebrow if parent_page?)
|
|
37
48
|
end
|
|
38
49
|
|
|
39
50
|
# The per-page SEO/social description → DocsUI::MetaTags (via Shell). Set it
|
|
40
51
|
# for a hand-tuned description; when unset, Page derives one from #lead, so
|
|
41
52
|
# an existing page gets a sensible description for free. nil (no
|
|
42
53
|
# description, no lead) falls back to config.seo.description in MetaTags.
|
|
54
|
+
# Inherited from a parent page when unset.
|
|
43
55
|
def description(value = nil)
|
|
44
56
|
@description = value if value
|
|
45
|
-
@description
|
|
57
|
+
@description || (superclass.description if parent_page?)
|
|
46
58
|
end
|
|
47
59
|
|
|
48
60
|
# The "On this page" auto-TOC placement for this page. Defaults to the
|
|
49
61
|
# configured DocsKit.configuration.on_page_default; set false to opt out,
|
|
50
|
-
# or :panel/:toggle/:sidebar to override per page.
|
|
62
|
+
# or :panel/:toggle/:sidebar to override per page. Inherited from a parent
|
|
63
|
+
# page when unset.
|
|
51
64
|
def on_page(value = :__unset__)
|
|
52
65
|
@on_page = value unless value == :__unset__
|
|
53
|
-
defined?(@on_page)
|
|
66
|
+
return @on_page if defined?(@on_page)
|
|
67
|
+
|
|
68
|
+
parent_page? ? superclass.on_page : DocsKit.configuration.on_page_default
|
|
54
69
|
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Whether the superclass is a page too (true below DocsUI::Page itself), so
|
|
74
|
+
# an unset macro falls back to it.
|
|
75
|
+
def parent_page? = superclass <= DocsUI::Page
|
|
55
76
|
end
|
|
56
77
|
|
|
57
78
|
def view_template
|
|
58
|
-
render DocsUI::Shell.new(
|
|
59
|
-
title: self.class.title,
|
|
60
|
-
description: self.class.description || lead,
|
|
61
|
-
on_page: self.class.on_page
|
|
62
|
-
) do
|
|
79
|
+
render DocsUI::Shell.new(title:, description:, on_page: self.class.on_page) do
|
|
63
80
|
# data-md-skip drops this nav from the Markdown export — it's chrome, not
|
|
64
81
|
# page content (DocsKit::MarkdownExport strips [data-md-skip]). The
|
|
65
82
|
# "Markdown" action sits opposite "← Home"; it's chrome too, so it lives
|
|
@@ -69,7 +86,7 @@ module DocsUI
|
|
|
69
86
|
render DocsUI::MarkdownAction.new(request.path) if markdown_action?
|
|
70
87
|
end
|
|
71
88
|
|
|
72
|
-
render DocsUI::Header.new(
|
|
89
|
+
render DocsUI::Header.new(title, eyebrow:) do
|
|
73
90
|
plain lead if lead
|
|
74
91
|
end
|
|
75
92
|
|
|
@@ -78,9 +95,19 @@ module DocsUI
|
|
|
78
95
|
end
|
|
79
96
|
|
|
80
97
|
# Whether to show the "Markdown" masthead action — the config knob
|
|
81
|
-
# (DocsKit.configuration.page_markdown_action, default true).
|
|
98
|
+
# (DocsKit.configuration.page_markdown_action, default true). The action links
|
|
99
|
+
# request.path + ".md"; override to false on a page whose route has no .md twin.
|
|
82
100
|
def markdown_action? = DocsKit.configuration.page_markdown_action
|
|
83
101
|
|
|
102
|
+
# The page title (masthead <h1> and <title>), eyebrow and SEO description.
|
|
103
|
+
# Each defaults to its class macro; override per instance when one page class
|
|
104
|
+
# renders many records. #title shadows Phlex's <title> element method, on
|
|
105
|
+
# pages only — the document <title> is DocsUI::Shell's, never a page body's.
|
|
106
|
+
def title = self.class.title
|
|
107
|
+
def eyebrow = self.class.eyebrow
|
|
108
|
+
# Falls back to #lead, then (in DocsUI::MetaTags) to config.seo.description.
|
|
109
|
+
def description = self.class.description || lead
|
|
110
|
+
|
|
84
111
|
# The lowercase authoring helpers md/prose/example come from DocsUI::PageHelpers
|
|
85
112
|
# (included above) — the parens-free path that never hits the constant-reference
|
|
86
113
|
# SyntaxError. The kit forms (DocsUI::Prose(), DocsUI::Example()) stay valid too.
|
|
@@ -72,9 +72,13 @@ module DocsUI
|
|
|
72
72
|
# injected by the controller under the active nav link (no content slot).
|
|
73
73
|
def content_toc? = %i[panel toggle].include?(@on_page)
|
|
74
74
|
|
|
75
|
+
# "Page · Suffix", or just the suffix on a title-less page. The suffix is
|
|
76
|
+
# resolved per render (a callable title_suffix/brand → this request's value).
|
|
77
|
+
def document_title = [@title, config.title_suffix_text].compact.join(" · ")
|
|
78
|
+
|
|
75
79
|
def render_head
|
|
76
80
|
head do
|
|
77
|
-
title {
|
|
81
|
+
title { document_title }
|
|
78
82
|
meta(charset: "utf-8")
|
|
79
83
|
meta(name: "viewport", content: "width=device-width,initial-scale=1")
|
|
80
84
|
# SEO / social-share tags (description, Open Graph, Twitter Card,
|
|
@@ -90,10 +94,20 @@ module DocsUI
|
|
|
90
94
|
meta(name: "turbo-refresh-scroll", content: "preserve")
|
|
91
95
|
config.stylesheets.each { |sheet| stylesheet_link_tag(sheet, data: { turbo_track: "reload" }) }
|
|
92
96
|
theme_restore_script
|
|
93
|
-
|
|
97
|
+
importmap_tags
|
|
94
98
|
end
|
|
95
99
|
end
|
|
96
100
|
|
|
101
|
+
# The import map tags for config.importmap_entry_point, from config.importmap
|
|
102
|
+
# when one is set. With nothing configured this is the helper's own default
|
|
103
|
+
# call, so the head matches a Shell that predates the knobs.
|
|
104
|
+
def importmap_tags
|
|
105
|
+
map = config.resolved_importmap
|
|
106
|
+
return javascript_importmap_tags(config.importmap_entry_point) if map.nil?
|
|
107
|
+
|
|
108
|
+
javascript_importmap_tags(config.importmap_entry_point, importmap: map)
|
|
109
|
+
end
|
|
110
|
+
|
|
97
111
|
# Restore the persisted theme BEFORE first paint so there's no flash of the
|
|
98
112
|
# server default before the docs-nav controller runs. Reads the same
|
|
99
113
|
# localStorage key the controller writes (docs-kit:<site>:theme). Runs on
|
|
@@ -183,9 +197,9 @@ module DocsUI
|
|
|
183
197
|
# text brand stays the mark's accessible-name fallback.
|
|
184
198
|
def brand_mark
|
|
185
199
|
if (logo = config.brand_logo)
|
|
186
|
-
render DocsUI::Logo.new(logo, class: "h-6 w-auto", label: config.
|
|
200
|
+
render DocsUI::Logo.new(logo, class: "h-6 w-auto", label: config.brand_text)
|
|
187
201
|
else
|
|
188
|
-
plain config.
|
|
202
|
+
plain config.brand_text
|
|
189
203
|
end
|
|
190
204
|
end
|
|
191
205
|
|
|
@@ -59,9 +59,9 @@ module DocsUI
|
|
|
59
59
|
# Slightly taller than the topbar's h-6: this is the masthead.
|
|
60
60
|
def brand_mark
|
|
61
61
|
if (logo = config.brand_logo)
|
|
62
|
-
render DocsUI::Logo.new(logo, class: "h-7 w-auto", label: config.
|
|
62
|
+
render DocsUI::Logo.new(logo, class: "h-7 w-auto", label: config.brand_text)
|
|
63
63
|
else
|
|
64
|
-
plain config.
|
|
64
|
+
plain config.brand_text
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
|
|
@@ -71,9 +71,12 @@ module DocsKit
|
|
|
71
71
|
# Revalidate on the rendered body itself (so any registry/config/page change
|
|
72
72
|
# busts it) plus the gem version as the etag salt. In development, always
|
|
73
73
|
# re-render; production sites deploy immutably so the version etag is stable.
|
|
74
|
+
# A callable brand (#92) makes the body per-request, so the response is then
|
|
75
|
+
# `private`: a shared cache must not hand one tenant's llms.txt to another.
|
|
74
76
|
def stale_llms?(body)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
shared = !docs_config.brand_per_request?
|
|
78
|
+
expires_in LLMS_MAX_AGE, public: shared
|
|
79
|
+
stale?(etag: [DocsKit::VERSION, body], public: shared)
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
# A page's Markdown twin, rendered through this controller's view context so
|
|
@@ -78,6 +78,17 @@ module DocsKit
|
|
|
78
78
|
# stylesheets (e.g. a separate rouge theme) lists them here.
|
|
79
79
|
attr_accessor :stylesheets
|
|
80
80
|
|
|
81
|
+
# The import map entry point the Shell loads. Defaults to "application"
|
|
82
|
+
# (javascript_importmap_tags' own default). A host with a dedicated docs
|
|
83
|
+
# import map names its entry point here, e.g. "docs".
|
|
84
|
+
attr_accessor :importmap_entry_point
|
|
85
|
+
|
|
86
|
+
# The import map the Shell renders: nil (the default → the host's
|
|
87
|
+
# Rails.application.importmap), an Importmap::Map, or a zero-arity callable
|
|
88
|
+
# returning one, resolved per render so a map rebuilt on code reload is
|
|
89
|
+
# picked up. Read via #resolved_importmap.
|
|
90
|
+
attr_accessor :importmap
|
|
91
|
+
|
|
81
92
|
# The Rouge theme class used by DocsUI::Code for inline syntax-highlight CSS.
|
|
82
93
|
# This is the BASE (light) theme, emitted un-scoped so it applies to every
|
|
83
94
|
# theme unless a dark override wins (see #code_theme_dark).
|
|
@@ -307,6 +318,8 @@ module DocsKit
|
|
|
307
318
|
@nav_registries = {}
|
|
308
319
|
@version_badge = nil
|
|
309
320
|
@stylesheets = %w[application]
|
|
321
|
+
@importmap_entry_point = "application"
|
|
322
|
+
@importmap = nil
|
|
310
323
|
@code_theme = "Rouge::Themes::Monokai"
|
|
311
324
|
@code_theme_dark = nil
|
|
312
325
|
@dark_themes = DEFAULT_DARK_THEMES
|
|
@@ -544,14 +557,54 @@ module DocsKit
|
|
|
544
557
|
|
|
545
558
|
public
|
|
546
559
|
|
|
560
|
+
# The localStorage namespace. A callable brand can't be slugged: the key must
|
|
561
|
+
# be stable per browser, not per request, so a callable brand needs an
|
|
562
|
+
# explicit c.nav_storage_key (checked again by DocsKit.configure).
|
|
547
563
|
def nav_storage_key
|
|
548
|
-
@nav_storage_key
|
|
564
|
+
return @nav_storage_key if @nav_storage_key
|
|
565
|
+
if @brand.respond_to?(:call)
|
|
566
|
+
raise DocsKit::Error,
|
|
567
|
+
"c.brand is a callable, so set c.nav_storage_key to a stable key " \
|
|
568
|
+
"(the sidebar/theme localStorage namespace must not change per request)"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
@brand.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
549
572
|
end
|
|
550
573
|
|
|
574
|
+
# The raw configured suffix (or the raw brand when unset). The chrome reads
|
|
575
|
+
# #title_suffix_text, which resolves a callable.
|
|
551
576
|
def title_suffix
|
|
552
577
|
@title_suffix || @brand
|
|
553
578
|
end
|
|
554
579
|
|
|
580
|
+
# The rendered brand. A String is returned as-is; a zero-arity callable is
|
|
581
|
+
# invoked on every read (never memoize it — the point is a per-request
|
|
582
|
+
# value, e.g. -> { Current.tenant_name }).
|
|
583
|
+
def brand_text
|
|
584
|
+
resolve_text(@brand).to_s
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# True when the brand is a callable, i.e. anything that embeds the brand
|
|
588
|
+
# (the chrome, llms.txt) may differ per request and must not be stored in a
|
|
589
|
+
# shared cache.
|
|
590
|
+
def brand_per_request?
|
|
591
|
+
@brand.respond_to?(:call)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# The rendered <title>/og:title suffix: #title_suffix resolved like
|
|
595
|
+
# #brand_text, falling back to the resolved brand. nil only when both are nil,
|
|
596
|
+
# so the title join drops it instead of leaving a dangling separator.
|
|
597
|
+
def title_suffix_text
|
|
598
|
+
resolve_text(@title_suffix.nil? ? @brand : @title_suffix)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Raises at configure time for a config the chrome can't render (today: a
|
|
602
|
+
# callable brand without a stable nav_storage_key). Called by DocsKit.configure.
|
|
603
|
+
def validate!
|
|
604
|
+
nav_storage_key
|
|
605
|
+
self
|
|
606
|
+
end
|
|
607
|
+
|
|
555
608
|
# Whether the built-in MCP endpoint is active: the #mcp toggle is on AND the
|
|
556
609
|
# optional `mcp` gem is loadable — the same "toggle AND capability" shape as
|
|
557
610
|
# #search_enabled?. Read by DocsKit::LlmsText (to advertise /mcp in llms.txt)
|
|
@@ -609,6 +662,12 @@ module DocsKit
|
|
|
609
662
|
@version_badge.to_s
|
|
610
663
|
end
|
|
611
664
|
|
|
665
|
+
# The import map the Shell renders, or nil for the host's default map. A
|
|
666
|
+
# callable is invoked on every read (once per render).
|
|
667
|
+
def resolved_importmap
|
|
668
|
+
@importmap.respond_to?(:call) ? @importmap.call : @importmap
|
|
669
|
+
end
|
|
670
|
+
|
|
612
671
|
# The default Rouge theme both #code_theme_class and #code_theme_dark_class
|
|
613
672
|
# fall back to when a configured theme name can't be resolved — so a typo'd
|
|
614
673
|
# theme name degrades gracefully instead of raising on every code block.
|
|
@@ -652,5 +711,12 @@ module DocsKit
|
|
|
652
711
|
rescue NameError
|
|
653
712
|
nil
|
|
654
713
|
end
|
|
714
|
+
|
|
715
|
+
# A String-or-callable knob's value for this render: a callable is invoked
|
|
716
|
+
# and stringified, nil stays nil, anything else is stringified.
|
|
717
|
+
def resolve_text(value)
|
|
718
|
+
value = value.call if value.respond_to?(:call)
|
|
719
|
+
value&.to_s
|
|
720
|
+
end
|
|
655
721
|
end
|
|
656
722
|
end
|
data/lib/docs_kit/engine.rb
CHANGED
|
@@ -36,10 +36,17 @@ module DocsKit
|
|
|
36
36
|
app.config.assets.paths << JAVASCRIPT_PATH.to_s if app.config.respond_to?(:assets)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Host-facing engine options. `config.docs_kit.auto_pin = false` (in
|
|
40
|
+
# config/application.rb) keeps the gem's pin out of the host's shared import
|
|
41
|
+
# map, for a host that pins `docs_kit/controllers` in a dedicated docs map.
|
|
42
|
+
config.docs_kit = ActiveSupport::OrderedOptions.new
|
|
43
|
+
config.docs_kit.auto_pin = true
|
|
44
|
+
|
|
39
45
|
# Auto-pin the controller for importmap-rails consumers, so a host app gets
|
|
40
46
|
# `docs_kit/controllers/docs_nav_controller` with no manual pin.
|
|
41
47
|
initializer "docs_kit.importmap", before: "importmap" do |app|
|
|
42
48
|
next unless app.config.respond_to?(:importmap)
|
|
49
|
+
next unless app.config.docs_kit.auto_pin
|
|
43
50
|
|
|
44
51
|
importmap = app.config.importmap
|
|
45
52
|
importmap.paths << root.join("config/importmap.rb") if importmap.respond_to?(:paths)
|
data/lib/docs_kit/llms_text.rb
CHANGED
|
@@ -33,7 +33,7 @@ module DocsKit
|
|
|
33
33
|
# single tight list (no blank lines between bullets), per the llmstxt.org
|
|
34
34
|
# convention.
|
|
35
35
|
def index(config, base_url: nil)
|
|
36
|
-
blocks = ["# #{config.
|
|
36
|
+
blocks = ["# #{config.brand_text}"]
|
|
37
37
|
tagline = config.tagline
|
|
38
38
|
blocks << "> #{tagline}" if tagline && !tagline.to_s.empty?
|
|
39
39
|
blocks.concat(section_blocks(config, base_url))
|
data/lib/docs_kit/mcp_server.rb
CHANGED
|
@@ -28,7 +28,7 @@ module DocsKit
|
|
|
28
28
|
return unless mcp_available?
|
|
29
29
|
|
|
30
30
|
server = MCP::Server.new(
|
|
31
|
-
name: config.
|
|
31
|
+
name: config.brand_text,
|
|
32
32
|
version: DocsKit::VERSION,
|
|
33
33
|
instructions: instructions_for(config, base_url),
|
|
34
34
|
server_context: { config:, base_url:, view_context: }
|
|
@@ -54,7 +54,7 @@ module DocsKit
|
|
|
54
54
|
lines = []
|
|
55
55
|
tagline = config.tagline
|
|
56
56
|
lines << tagline.to_s if tagline && !tagline.to_s.empty?
|
|
57
|
-
lines << "Read-only documentation tools for #{config.
|
|
57
|
+
lines << "Read-only documentation tools for #{config.brand_text}. " \
|
|
58
58
|
"Use search_docs to find sections, get_page to read a page's Markdown, " \
|
|
59
59
|
"and list_pages to enumerate the docs. Full index: #{llms}."
|
|
60
60
|
lines.join(" ")
|
data/lib/docs_kit/version.rb
CHANGED
data/lib/docs_kit.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: docs-kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -179,6 +179,7 @@ files:
|
|
|
179
179
|
- app/components/docs_ui/logo.rb
|
|
180
180
|
- app/components/docs_ui/markdown.rb
|
|
181
181
|
- app/components/docs_ui/markdown_action.rb
|
|
182
|
+
- app/components/docs_ui/markdown_images.rb
|
|
182
183
|
- app/components/docs_ui/meta_tags.rb
|
|
183
184
|
- app/components/docs_ui/on_this_page.rb
|
|
184
185
|
- app/components/docs_ui/open_api_operation.rb
|